Skip to content

使用Node.js和typescript开发CLI工具的实践

使用typescript打包成符合ES模块规范的代码,在NodeJS平台上运行一款CLI工具,整个开发过程的落地实践。

背景介绍

目前NodeJS还不能原生地支持typescript(TS),如果基于NodeJS开发项目时想使用TS,需要额外引入相关的工具库,将 typescript 编译成 JavaScript 再交由NodeJS引擎去执行。 开发NodeJS的应用,一般使用commonJS的模块规范,如过想使用ES模块的规范,需要做一些额外的处理(更改配置或者使用类似webpack这样的打包工具)。 开发一款CLI工具,一般把执行脚本放在bin目录下,由系统中的node进程来运行这些JS。在开发的过程中如何随时编写TS代码,随时测试CLI工具的效果呢。 以上三点是在开发过程中需要解决的问题。

实践方案

搭建环境

项目目录TS编译配置 工程目录,如上面的左图所示,src目录存放TS源码文件,bin目录存放编译后的可执行JS文件。 从上面的右图,可以看出TS编译后,直接导出到了bin目录,生成了符合ES模块规范的目标代码。

自动构建

注册命令运行效果 开放CLI工具时,一般需要链接到本地的目录,执行 npm link -f .命令,就可以在本地运行注册好的CLI命令了。以本项目为例,运行的效果如上面的右图所示。 那么回到一开始的问题,如何做到一边写ts代码,一边跑本地的命令调试效果呢。可能很多小伙伴会想到,ts有实时编译的功能吗。直接执行命令tsc --watch就好了吧。 这样做的话,会有一个问题:编译出的js代码无法直接被node运行。

javascript
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main'; // 引用本地相对路径模块
import { author } from './config'; // 引用本地相对路径的模块
const init = async function () {}
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main'; // 引用本地相对路径模块
import { author } from './config'; // 引用本地相对路径的模块
const init = async function () {}

上面的引用本地相对路径模块的代码,不能被node有效识别。需要转换成下面的格式才可以。

javascript
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main.js'; // 引用本地相对路径模块()
import { author } from './config.js'; // 引用本地相对路径的模块
const init = async function () {}
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main.js'; // 引用本地相对路径模块()
import { author } from './config.js'; // 引用本地相对路径的模块
const init = async function () {}

需要在引入的本地模块加上js的后缀才能正常运行。因此编写了一个自动构建的脚本,实时监测文件的变化,重写本地模块的路径。

javascript
/**
 * update the import path for supporting the ESModule
 * @param string distJsPath
 */
const updateImportPath = (distJsPath) => {
  childProcess.exec('npm run repath', (error, stdout, stderr) => {
    if (error !== null) {
      console.log('execute command error: ', stderr);
    }
    console.log(stdout);
    console.log(`update the imported path of file [${distJsPath}] done`);
  });
};

/**
 * monitoring the file change and compile typescript
 */
const watchBuilding = () => {
  tsPaths.forEach(tsPath => {
    fs.watch(tsPath, { recursive: true }, throttle((evt, filepath) => {
      if (!tsReg.test(filepath)) return;
      console.log(`${evt} in ${filepath}, ready to compile`);
      const jsPath = filepath.replace(tsReg, '.js');
      const distJsPath = compiledTSPath + '/' + jsPath;
      if (evt === fileEvent.rename) {
        if (fs.existsSync(distJsPath)) {
          fs.rmSync(distJsPath);
          console.log(`remove compiled js file [${distJsPath}].`);
        }
        return;
      }
      childProcess.exec('npm run build-ts', (error, stdout, stderr) => {
        if (error !== null) {
          console.log('execute command error: ', stderr);
        }
        console.log(stdout);
        if (fs.existsSync(distJsPath)) {
          setTimeout(() => updateImportPath(distJsPath), 100);
        }
        console.log(`compile typescript file [${filepath}] done`);
      });
    }, delay));
  });
};
/**
 * update the import path for supporting the ESModule
 * @param string distJsPath
 */
const updateImportPath = (distJsPath) => {
  childProcess.exec('npm run repath', (error, stdout, stderr) => {
    if (error !== null) {
      console.log('execute command error: ', stderr);
    }
    console.log(stdout);
    console.log(`update the imported path of file [${distJsPath}] done`);
  });
};

/**
 * monitoring the file change and compile typescript
 */
const watchBuilding = () => {
  tsPaths.forEach(tsPath => {
    fs.watch(tsPath, { recursive: true }, throttle((evt, filepath) => {
      if (!tsReg.test(filepath)) return;
      console.log(`${evt} in ${filepath}, ready to compile`);
      const jsPath = filepath.replace(tsReg, '.js');
      const distJsPath = compiledTSPath + '/' + jsPath;
      if (evt === fileEvent.rename) {
        if (fs.existsSync(distJsPath)) {
          fs.rmSync(distJsPath);
          console.log(`remove compiled js file [${distJsPath}].`);
        }
        return;
      }
      childProcess.exec('npm run build-ts', (error, stdout, stderr) => {
        if (error !== null) {
          console.log('execute command error: ', stderr);
        }
        console.log(stdout);
        if (fs.existsSync(distJsPath)) {
          setTimeout(() => updateImportPath(distJsPath), 100);
        }
        console.log(`compile typescript file [${filepath}] done`);
      });
    }, delay));
  });
};

核心的方法是上面列出的两个:watchBuilding(监视typescript的编译)、updateImportPath(更新引入路径)。更多内容可以参考后面工程项目。在本地开发过程中,只需启动一个npm run compile命令即可实现,ts ---》 js以及路径的重新。使用ts开发完一个小功能后,即可通过link后的cmd命令运行打包后的js文件。

无法加载ESModule

项目中引入了一些三方的类库,在使用ts引入报如下的错误:

bash
src/index.ts:4:8 - error TS1259: Module '"/Users/ancai/code/leah-cli/node_modules/@types/inquirer/index"' can only be default-imported using the 'allowSyntheticDefaultImports' flag

4 import inquirer from 'inquirer';
         ~~~~~~~~

  node_modules/@types/inquirer/index.d.ts:982:1
    982 export = inquirer;
        ~~~~~~~~~~~~~~~~~~
    This module is declared with using 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag.
src/index.ts:4:8 - error TS1259: Module '"/Users/ancai/code/leah-cli/node_modules/@types/inquirer/index"' can only be default-imported using the 'allowSyntheticDefaultImports' flag

4 import inquirer from 'inquirer';
         ~~~~~~~~

  node_modules/@types/inquirer/index.d.ts:982:1
    982 export = inquirer;
        ~~~~~~~~~~~~~~~~~~
    This module is declared with using 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag.

解决方法 在tsconfig配置文件中 compilerOptions项下 添加 allowSyntheticDefaultImports = true,表示允许使用合成的默认导入。

json
{
  "compilerOptions": {
    "target": "ES2019",
    "module": "ESNext",
    "allowSyntheticDefaultImports": true
  },
}
{
  "compilerOptions": {
    "target": "ES2019",
    "module": "ESNext",
    "allowSyntheticDefaultImports": true
  },
}

编译出src路径

如果引入到了src外面的文件,比如引入import pkg from '../package.json'会导致编译后的目录有src路径,这可能是开发人员不希望看到的。 输出src 解决方案:避免引入src之外的模块;改变ts编译配置。

  1. 将所有的源码模块全部放在src目录下,就不会出现该问题。
  2. 参考文献3 改一下ts的变异配置也可以解决该问题。

https://stackoverflow.com/questions/52121725/maintain-src-folder-structure-when-building-to-dist-folder-with-typescript-3

import JSON file error

导入json模块报错

jest不支持ESM

项目中使用jest做单元测试时,出现下面的错误,也是由于jest在node运行环境下不能很好地兼容esm模块规范所致。 jest不支持esm模块 解决办法如下:添加额外的参数--experimental-vm-modules node_modules/.bin/jest. node --experimental-vm-modules node_modules/.bin/jest 取代单独的 jest 命令 更详细的内容,可以参考文献7

相关参考

  1. https://github.com/microsoft/TypeScript-Node-Starter
  2. https://github.com/jeroenouw/ExampleCLI
  3. https://github.com/ioleo/ts-mocha-example
  4. https://stackoverflow.com/questions/60205891/import-json-extension-in-es6-node-js-throws-an-error
  5. https://segmentfault.com/q/1010000038671707
  6. https://stackoverflow.com/questions/60935889/cant-do-a-default-import-in-angular-9
  7. https://stackoverflow.com/questions/59709939/jest-cannot-use-import-statement-outside-a-module